Visualizing Geographic Information

Published

June 21, 2025

In this section we will see some recipes to display geographic information (i.e. data with have some spatial context). Here are some useful definitions:

Geographic Information
Geographic Information is data that includes spatial components — usually coordinates (latitude/longitude), geometric data (points, lines, polygons), and possibly additional attributes related to the geometric data.
Maps
Maps are visual representations of geographic information. We’ll often use a base map, which gives the base geographical context, with other spatial features (points, polygons, labels) which provides additional visual information.
Choropleth Map
A special type of map where geographic regions are displayed with information (often color) based on a data value, like area, population, count of objects inside the region, etc.
Layers
Layers are the components of a map, stacked to combine geographically-linked information. For example, we can create a map with a layer for the boundaries, another one for major cities, another one with labels and so on.

Before we start…

Most of the data used in this section can be downloaded from the Instituto Brasileiro de Geografia e Estatística - Malha Municipal’s site. The data is stored in shapefiles, files with the same name but different file extensions that contains the coordinates for the geographic objects, projection, associated data, etc.

Files downloaded from the IBGE site are zip (compressed) files containing all files associated with that shapefile. When reading shapefiles we only need to open the .shp file – all associated files will be open and read automatically. In these examples we assume that the zip files were downloaded and stored in local folder.

Let’s see how to read a shapefile and get basic information. First let’s import all the libraries we will use in this section.

import geopandas as gpd
import json
import plotly.express as px
import plotly.colors as pc
import plotly.graph_objects as go

I prefer to use Plotly for visualization – there are other alternatives but I think it is more flexible and the plots and charts are interactive and visually more attractive.

Let’s read the Brazil’s states shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_UF_2024.shp"
# Read shapefile
gdf = gpd.read_file(shapefile_path)

What’s in the shapefile? Let’s display its first few rows.

print(gdf.head())
  CD_UF           NM_UF SIGLA_UF CD_REGIA NM_REGIA SIGLA_RG     AREA_KM2  \
0    35       São Paulo       SP        3  Sudeste       SE   248219.485   
1    15            Pará       PA        1    Norte        N  1245828.829   
2    32  Espírito Santo       ES        3  Sudeste       SE    46074.448   
3    12            Acre       AC        1    Norte        N   164082.960   
4    13        Amazonas       AM        1    Norte        N  1558706.127   

                                            geometry  
0  MULTIPOLYGON (((-48.03541 -25.35682, -48.0355 ...  
1  MULTIPOLYGON (((-50.84599 -9.80064, -51.05801 ...  
2  MULTIPOLYGON (((-40.88336 -21.16372, -40.88345...  
3  POLYGON ((-68.39021 -11.04496, -68.39073 -11.0...  
4  POLYGON ((-67.51732 -9.56071, -67.51776 -9.560...  

For the states’ shapefile we have a dataframe with one state per record, with information on names, abbreviations of the state and region, its area and geometry – this is a set of geometric structures and coordinates that will be used to draw the data boundaries.

We can use the data in the shapefiles to do some queries (e.g. which tis the largest state in the NE region), but we don’t need to use the geometry directly – there are functions that use it.

Usually shapefiles have some type of index (in this example, CD_UF) that can be associated to an external data source to create, for example, rich choropleths.

Geometry simplification

As mentioned earlier, shapefiles store coordinates that define the shapes of geographic features. Official maps often include highly detailed polygons with a large number of coordinate points.

In this section, we’ll display maps on a computer screen. Even when zooming in, we rarely need that level of detail. Using the original, high-resolution coordinates increases memory usage and computational load — even simple tasks like rendering a map in a web page can become noticeably slow. For this reason, it’s often necessary to reduce the geometric complexity of shapefiles before displaying them.

There is a simple method that can be used to reduce the complexity of geometries in shapefiles: simplify. Here is an example of its usage:

shapefile_path = "Resources/Data/Shapefiles/SP_UF_2024.shp"
shapeSP = gpd.read_file(shapefile_path)
# Create a simplified copy.
shapeSP_simplified = shapeSP.copy()
shapeSP_simplified["geometry"] = \
  shapeSP_simplified["geometry"].simplify(tolerance=0.001, preserve_topology=False)

Please refer to shapely‘s documentation for information and example of the methods’ parameters.

Using simplify may trigger a warning in some cases, particularly when a geometry is smaller than the specified tolerance. To avoid this, you can try using a smaller tolerance value. In practice, it may take some adjustment to find an appropriate value, but for simple visualization purposes, the warning can often be safely ignored.

Simple Maps

One layer: Brazil’s boundaries

Let’s start with a very simple map, containing the coordinates for Brazil’s boundaries. Let’s read the shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_Pais_2024.shp"
# Read shapefile
shapeBR_Raw = gpd.read_file(shapefile_path)
# Simplify the geometries for faster processing and rendering!
shapeBR = shapeBR_Raw.copy()
shapeBR["geometry"] = shapeBR["geometry"].simplify(tolerance=0.001, preserve_topology=False)
# How many records do I have?
print(len(shapeBR))
1

As expected, the shapefile for Brazil contains only one record.

To display the shapefile we loaded, first we create a choropleth using the shapefile as the source of the data and a field of the shapefile as the source of the (GeoJSON) geographic coordinates. We will also use the field PAIS to set the color of the map and set the fields’ values that will appear when we hover on the map.

fig = px.choropleth(
    shapeBR,
    geojson=shapeBR.__geo_interface__,
    locations=shapeBR.index,
    color="PAIS",  
    hover_name="PAIS",
    color_discrete_sequence=["#009440"],
    custom_data=["PAIS", "AREA_KM2"],
    projection="mercator"
)

The code below sets the format of the hover message, adjusts the bounds of the figure so it will fill the whole plot area and set some values for the maps’ appearance:

fig.update_geos(fitbounds="locations", visible=False)
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Area: %{customdata[1]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_layout(
        title="Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

You can zoom and hover the map for more information!

Just for fun, let’s see what happens when we oversimplify this shapefiles’ geometries. First we create another version of the simplified shapefile, with a much larger tolerance (much simpler shapes):

# Simplify the geometries for faster processing and rendering!
shapeBR2 = shapeBR_Raw.copy()
shapeBR2["geometry"] = \
    shapeBR2["geometry"].simplify(tolerance=0.75, preserve_topology=False)

And display it:

fig = px.choropleth(
    shapeBR2,
    geojson=shapeBR2.__geo_interface__,
    locations=shapeBR2.index,
    color="PAIS",  
    color_discrete_sequence=["#009440"],
    projection="mercator"
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_traces(hovertemplate="<extra></extra>")  # disables hover
fig.update_layout(
        title="Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

The features are still recognizable even with a much larger tolerance!

One layer: Boundaries of Brazil’s States

We can reuse the code to display a map of Brazil’s states. We need only to load a different shapefile:

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/BR_UF_2024.shp"
# Read shapefile
shapeUF_Raw = gpd.read_file(shapefile_path)
# Simplify the geometries for faster processing and rendering!
shapeUF = shapeUF_Raw.copy()
shapeUF["geometry"] = shapeUF["geometry"].simplify(tolerance=0.05, preserve_topology=False)
# How many records do I have?
print(len(shapeUF))
27

Let’s display it, reusing the code for the whole country. We will change the thickness of the lines.

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],
    color_discrete_sequence=["#009440"],
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="yellow")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

One layer: Boundaries of Brazil’s States (different colors per state)

In the previous example all the states were displayed with the same color. Let’s see how we can show each one in a different color:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_discrete_sequence=px.colors.qualitative.Alphabet,
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

We can create discrete color palettes from continuous ones with this:

def my_colorscale(n, scale='Rainbow'):
    return pc.sample_colorscale(pc.get_colorscale(scale), [i / (n - 1) for i in range(n)])

And use it to plot the maps:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_discrete_sequence=my_colorscale(27,'Viridis'), 
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0}
    )

One layer: Boundaries of Brazil’s States (colors based on area)

Let’s create a simple choropleth by using the information about each state area to assign a color to it. If the information is already associated to the dataframe of the shapefile it is just a case of selecting the field as the color and using a proper continuous scale:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="AREA_KM2",  
    hover_name="NM_UF",
    custom_data=["NM_UF","NM_REGIA","AREA_KM2"],    
    color_continuous_scale='YlGnBu',
    projection="mercator"
)
fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  # Name
                  "Region %{customdata[1]}<br>" +  # Region
                  "Area: %{customdata[2]:,.0f} km²<br>" +  # Area
                  "<extra></extra>"  # Hide trace name
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
        title="States of Brazil",
        showlegend=False,
        margin={"r":0,"t":40,"l":0,"b":0},
        coloraxis_colorbar=dict(
           title="Área (km²)",  
           tickformat=".0f",    
           lenmode="pixels", len=450,  
           thickness=20          
        )
    )

One layer: Boundaries of Brazil’s States (conditional coloring)

Eventually we would like to highlight some polygons on the choropleth based on conditional information. This is easy to do if we create another column on the dataframe that will be used as a filter and that will create a category associated to each row.

Let’s see a simple example: I want to annotate all states in the shapefile with a field that will indicate if the state is on the North region. Here’s the code to do this:

shapeUF["isNorth"] = (shapeUF["NM_REGIA"] == "Norte").map({True: "Yes", False: "No"})

This seems redundant since we already have a field that indicates that the state is on the North region, but creating an additional field will give us more flexibility later.

The second step is to create a colormap that will be used when creating the cloropleth. The colormap must associate a color to each possible value of our filter column:

map_colors = {"Yes": "#009440","No": "#dddddd"}

Now we can plot the map, using basically the same approach as in other examples on this section:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="isNorth", 
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_map=map_colors,
    projection="mercator"
)

fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  
                  "Region %{customdata[1]}<br>" +  
                  "Area: %{customdata[2]:,.0f} km²<br>" +
                  "<extra></extra>"
)

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="States of the North Region of Brazil",
    showlegend=False,
    margin={"r":0,"t":40,"l":0,"b":0}
)

It is easy to create more complex color rules using the data that is already part of the shapefile’s dataframe. Here is an example of a function that will return NE for states in the northeast, SE for regions in the southeast but only if the states’ area is larger than 100.000km². The function will return Other for states that does not match these criteria.

def mark_larger(row):
    area = row["AREA_KM2"]
    region = row["NM_REGIA"]
    if (area > 100000):
        if region == "Nordeste":
            return "NE"
        elif region == "Sudeste":
            return "SE"
        else:
            return "Other"
    else:   
        return "Other"        

Now we can create a new column on the dataframe to represent the category of the state, accordingly to the function we created:

shapeUF["Category"] = shapeUF.apply(mark_larger, axis=1)

We need a map for the colors for each category:

map_colors = {"NE": "#0D7DBD","SE": "#F79322","Other": "#dddddd"}

Now we can plot the map:

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="Category", 
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_map=map_colors,
    projection="mercator"
)

fig.update_traces(marker_line_width=2, marker_line_color="#808080")
fig.update_traces(
    hovertemplate="<b>%{customdata[0]}</b><br>" +  
                  "Region %{customdata[1]}<br>" +  
                  "Area: %{customdata[2]:,.0f} km²<br>" +
                  "<extra></extra>"
)

fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="Larger States in the Northeast and Southeast Regions of Brazil",
    showlegend=False,
    margin={"r":0,"t":40,"l":0,"b":0}
)

Two layers: Boundaries of Brazil and States

fig = px.choropleth(
    shapeUF,
    geojson=shapeUF.__geo_interface__,
    locations=shapeUF.index,
    color="CD_UF",  
    hover_name="NM_UF",
    custom_data=["NM_UF", "NM_REGIA", "AREA_KM2"],
    color_discrete_sequence=["#009440"],
    projection="mercator"
)
# Outline layer (entire Brazil — no color fill, just black outline)
outline = go.Choropleth(
    geojson=shapeBR.__geo_interface__,
    locations=shapeBR["PAIS"],
    featureidkey="properties.PAIS",  # must match GeoJSON property
    z=[0]*len(shapeBR),  # one z value per feature
    showscale=False,
    marker_line_color='black',
    marker_line_width=3,
    colorscale=[[0, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0)']],
    name="Brasil"
)

Let’s add the trace to the base map. (we will use the dummy vatiable _ to avoid the display of the temporary map).

_ = fig.add_trace(outline)
for trace in fig.data:
    if trace.type == 'choropleth' and trace.name != 'Brasil':
        trace.hovertemplate = (
            "<b>%{customdata[0]}</b><br>" +  # Name
            "Region: %{customdata[1]}<br>" +
            "Area: %{customdata[2]:,.0f} km²<br>" +
            "<extra></extra>"
        )
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
    title="States of Brazil",
    showlegend=False,
    margin={"r": 0, "t": 40, "l": 0, "b": 0}
)

TEST 3

# Path to the .shp file.
shapefile_path = "Resources/Data/Shapefiles/MG_Municipios_2024.shp"
# Read shapefile
shapeMMG_Raw = gpd.read_file(shapefile_path)
# Simplify the geometries for faster processing and rendering!
shapeMMG = shapeMMG_Raw.copy()
shapeMMG["geometry"] = shapeMMG["geometry"].simplify(tolerance=0.05, preserve_topology=False)
# How many records do I have?
print(len(shapeUF))
print(json.dumps(shapeMMG.__geo_interface__['features'][0]['properties'], indent=2))
27
{
  "CD_MUN": "3104106",
  "NM_MUN": "Arceburgo",
  "CD_RGI": "310044",
  "NM_RGI": "Guaxup\u00e9",
  "CD_RGINT": "3108",
  "NM_RGINT": "Varginha",
  "CD_UF": "31",
  "NM_UF": "Minas Gerais",
  "SIGLA_UF": "MG",
  "CD_REGIA": "3",
  "NM_REGIA": "Sudeste",
  "SIGLA_RG": "SE",
  "CD_CONCU": null,
  "NM_CONCU": null,
  "AREA_KM2": 162.875
}